Hono 框架实战与 mastra.ai 接入
前两节设计了 agents 和 tools 的纯逻辑层,这节用 Hono 框架把它们暴露为 HTTP API,接入 mastra.ai,并完整走一遍"天气 Agent + Playground 自定义 Provider"的实战链路。
为什么选 Hono
Hono 是轻量级 Web 框架,2024-2026 年在前端社区快速流行,已成为边缘计算和 AI API 服务的主流选择(npm 周下载量数百万,2026-11-07 核实最新 4.13.16,本项目锁 ^4.10):
- 多运行时:Cloudflare Workers、Bun、Node.js、Deno 都能跑
- 零依赖:核心包只有 14KB
- TypeScript 优先:类型安全,路由推断
- Express 风格 API:上手成本几乎为零——第10章的 mock server 已经用它响应过 HTTP 与流式请求
对比其他方案:
| 框架 | 体积 | 运行时 | TypeScript |
|---|---|---|---|
| Express | 572KB(minified) | Node.js | 需要额外配置 |
| Fastify | 80KB+ | Node.js | 支持 |
| Hono | 14KB | 多运行时 | 原生支持 |
对于 AI 应用的 API 服务,Hono 够轻够快。
@ai-chat/server 包搭建
cd packages/server
pnpm init
pnpm add hono @ai-chat/agents @ai-chat/tools
bash
基本结构(真实仓库落地形态):
packages/server/src/
index.ts # 入口:导出 createChatGateway
create-gateway.ts # 网关工厂
routes/
chat.ts # POST /chat(SSE 流式)
models.ts # GET /models
health.ts # GET /health 探活
middleware/
auth.ts # Bearer 认证
rate-limit.ts # 内存固定窗口限流
identity/ # 用户体系(19 章扩展,本章不展开)
mastra/ # createMastraGateway(16-01 加的可选子路径)
text
HTTP 路由设计
聊天接口
核心接口,前端通过它对话。真实源码(节选):
// packages/server/src/routes/chat.ts
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
export function createChatRoutes(deps: ChatRoutesDeps): Hono {
const app = new Hono()
app.post('/', async (c) => {
const body = await c.req.json().catch(() => null)
if (!body || !Array.isArray(body.messages) || body.messages.length === 0) {
return c.json({ error: 'messages is required' }, 400)
}
const registered = deps.registry.get(resolveModelId(body.model, deps))
const passthrough = { ...body } // messages/model 之外的字段整体透传
delete passthrough.messages
delete passthrough.model
return streamSSE(c, async (stream) => {
const collector = createAssistantCollector(generateId())
try {
for await (const chunk of registered.adapter.chatStream({
messages: body.messages,
signal: c.req.raw.signal, // 客户端断开即中止上游
passthrough,
})) {
if (stream.aborted) break
collector.push(chunk)
await stream.writeSSE({ event: 'chunk', data: JSON.stringify(chunk) })
if (chunk.type === 'done') break
}
// 适配器未发 done 即结束:兜底补帧,保持线协议收尾
} catch (err) {
const errorChunk: StreamChunk = { type: 'error', content: String(err) }
await stream.writeSSE({ event: 'chunk', data: JSON.stringify(errorChunk) })
}
})
})
return app
}
typescript
三个工程细节值得学:SSE 输出用 Hono 的 streamSSE 助手(自动设 SSE 响应头);c.req.raw.signal 把客户端断开透传成上游 AbortSignal;messages/model 之外的字段(如 conversationId、mock 的 speed)整体作为 passthrough 透传给适配器,网关不解释。
模型列表接口
// packages/server/src/routes/models.ts
export function createModelsRoutes(registry: ModelRegistry): Hono {
const app = new Hono()
app.get('/', (c) => c.json(registry.list())) // 脱敏视图:无 apiKey
return app
}
typescript
中间件体系
网关工厂把中间件按层装配(真实源码 create-gateway.ts 简化):
export function createChatGateway(options: GatewayOptions): Hono {
const registry = /* options.models 或数组逐个 register */
const app = new Hono().basePath(options.basePath ?? '/api')
if (options.cors !== false) app.use('*', cors({ origin: reflectOrigin }))
if (options.logging) app.use('*', logger())
// 认证与限流只挂业务路由,/health 保持开放(探活不应被锁)
if (options.auth) {
app.use('/chat', bearerAuth(options.auth))
app.use('/models', bearerAuth(options.auth))
}
if (options.rateLimit) {
app.use('/chat', rateLimit(options.rateLimit))
app.use('/models', rateLimit(options.rateLimit))
}
app.route('/chat', createChatRoutes({ registry, ...options.chat }))
app.route('/models', createModelsRoutes(registry))
app.route('/health', createHealthRoutes())
return app
}
typescript
限流中间件是内存固定窗口实现:按 key(默认 x-forwarded-for 首段,即代理链上的客户端真实 IP)在 windowMs(默认 60s)内计数,超过 max(默认 30)返回 429 并带 retry-after 头。注意它是单实例有效的——多实例部署需换外部存储。
mastra.ai 接入
mastra.ai 是 TS 写的 Agent 框架(15-02 选型结论),提供模型路由、工具调用、记忆和监控。
安装与初始化
嵌入已有项目不需要 mastra init(那是新建独立 Mastra 项目的脚手架),直接装运行时包:
pnpm add @mastra/core @mastra/memory @mastra/libsql zod
bash
Agent 在构造函数里用 "provider/model" 字符串直接指定模型,无需配置注册表:
import { Agent } from '@mastra/core/agent'
const agent = new Agent({
id: 'my-agent',
name: 'My Agent',
instructions: 'You are a helpful assistant',
model: 'openai/gpt-5-mini', // 或 'anthropic/claude-sonnet-4-5' / 'google/gemini-2.5-flash'
})
typescript
Mastra 会自动从 OPENAI_API_KEY、ANTHROPIC_API_KEY、DEEPSEEK_API_KEY 等环境变量读取密钥(缺 key 时报错会写明缺哪个变量)。自定义 OpenAI 兼容端点(DeepSeek/GLM/Ollama 等)用对象形态:
model: { id: 'openai/my-qwen3', url: 'http://your-endpoint/v1', apiKey: '...' }
typescript
注意 model id 必须是 provider/model 路由串——裸模型名在首次调用时解析失败(报 "doesn't appear to contain a provider"),真实仓库的运行时注册统一给裸名补默认前缀。
官方天气示例拆解:模型是怎么"知道"工具的
Mastra 官方入门示例是一个天气工具 + Agent。真实仓库的完整实现(packages/dev-server/src/tools/get-weather.ts):
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
export const getWeatherTool = createTool({
id: 'get_weather',
description: '获取指定城市的当前天气(wttr.in)',
inputSchema: z.object({
city: z.string().describe('城市名,如 Beijing'),
}),
execute: async ({ city }) => {
const response = await fetch(
`https://wttr.in/${encodeURIComponent(city)}?format=j1`,
{ signal: AbortSignal.timeout(5000) }
)
if (!response.ok) throw new Error(`wttr.in ${response.status}`)
const data = await response.json()
const current = data.current_condition?.[0]
return {
city,
temperatureC: current?.temp_C ?? '',
description: current?.weatherDesc?.[0]?.value ?? '',
}
},
})
typescript
wttr.in 是免费天气接口,按 IP 位置或城市名返回 JSON——先在浏览器里访问通这个接口,再写工具,是稳妥的顺序。
模型"知道"这个工具的机制拆开看很朴素:工具的 id、description 和参数 schema 会作为请求的一部分随消息一起发送给大模型。模型解析用户意图(用户要查天气、且给了地点)→ 从工具清单里选出 get_weather 并填参调用 → 工具响应回来 → 模型把结果整合后输出给用户。所谓"函数调用",底层就是模型按你要求的格式响应函数名和参数(早期实现甚至是正则匹配解析的),框架再把这坨结构化文本真正映射成函数执行。把这个流程看一遍,Agent 就祛魅了——预制好的提示词 + 预制好的工具,让模型在你的控制范围内完成调度。
定义完工具,把它传给 Agent,Agent 就拥有了对应能力:
// packages/dev-server/src/agents/chat-agent.ts(真实源码)
export function chatAgentDefinition(config): MastraAgentDefinition {
return {
id: 'chat-agent',
name: 'Chat Agent',
model: resolveModelConfig(config),
instructions: '你是 ai-chat-ui 的演示 Agent。需要时间或天气信息时调用对应工具,回答保持简洁。',
tools: { getTimeTool, getWeatherTool },
memory: createMemory, // 工厂形式,惰性创建
}
}
typescript
AI 方案走偏了怎么办:提示词要迭代到"可供落地"
第一次让 AI 接入 Mastra 的提示词朴实无华——直接把官方文档链接发给它:"整合到我们的项目中来"。它确实读到了官方代码,但给出的方案走偏了:通过环境变量传递模型参数、对接一个写死的上游模型。这与组件库的预期不符:
- 前端技术组件没有模型选择能力,模型写死在后台等于组件不通用;
- 想切换模型,或用户想用自己的 API 接口验证测试组件,都不方便。
于是追加一组具体到"可供落地"的需求提示词:
> 专门创建一个基础组件,用于添加不同的 provider(如 OpenAI、Anthropic 等)。
> 示例页就是 playground 页面:加入一个设置按钮,点击后弹出设置 provider 的对话框,
> 设置完成即可在页面中进行会话。添加后可以在页面中选择模型、切换模型。
> 默认加载天气工具;在 thinking 里显示用了什么工具、结果是什么,最后整合回复。
text
AI 拿到这组需求后给出的设计链路,与最终真实实现一致:
最终效果实测:在 Provider 设置里添加一个模型(比如自己的 OpenAI 兼容端点)→ 发送"武汉、北京、上海天气怎么样"→ 消息区先展示思考过程(thinking)→ 出现 get_weather 工具调用与响应数据 → 最后把三个城市的天气拼接成完整回复。整条链路里 Mastra Agent 就是决策中枢:由用户消息判断是否需要执行天气工具。
三个坑点与修复思路
这一轮 AI 开发出来的成果能用,但离精细还差三口气——这三个坑点也是你自己做同类功能时的自查清单:
坑一:Provider 设置的样式细节。 弹窗里不同控件宽高不一致(有的上下偏宽、有的偏窄)。这类纯视觉问题不用自己抠——用 ui-ux-pro-max 这类设计 skill 让 AI 重新设计整个技术组件的视觉与布局。
坑二:左侧会话列表没有智能命名。 新开了会话,左侧列表的名称纹丝不动。修复思路:对接模型 API 后,选一个便宜、响应快的轻思考模型,把会话第一个 prompt 发给它生成标题,自动重命名。这是"按角色选模型"的成本意识的第一个落地场景(16-03 成本四板斧会展开)。
坑三:流式消息缺少过渡反馈。 工具调用完成后,后面的消息一个字一个字吐出来——期间没有任何提示。需要给消息回复加加载/过渡效果(同样可以交给 UI/UX 交互设计 skill)。
本地存储的取舍
模型添加与注册的信息(provider 配置)存在哪?课程给的取舍:服务端没有用户系统,这些信息全部存用户侧浏览器的 localStorage。好处立竿见影——换一个人访问 playground,他依旧可以添加自己的 OpenAI 或 Anthropic 接口、配置自己的模型使用,互不干扰。
真实仓库落地时把这个决策写得更细(packages/playground/src/composables/useProviderModels.ts):服务端 registry 是进程内存态、重启归零,所以浏览器侧用 SAVED_PROVIDERS_STORAGE_KEY 把"记住配置"的完整表单(含 apiKey)持久化,服务端启动时按存储逐条静默重注册即可恢复——这是 Playground 私有决策(私有演示包图方便);发布的组件本身不落任何存储,勾选状态经 payload.persist 交宿主决定。
留给读者的练习
页面上还缺的功能:消息复制按钮、重新发送、消息编辑、重新生成。还有一个交互设计题:重新发送某条消息后,它后面的旧消息怎么办——是移除掉,还是保留让用户在多个历史回复间切换(最早的 ChatGPT 支持在页面上切换之前的消息回复)?这些按各自喜好设计。本节的重点只有一个:把工具调用跑通,并且在思考过程里把工具调用的状态展示出来。
实战:接入 ai-chat-ui 平台(2026-08 实测)
ai-chat-ui 已把 Mastra 接入 @toimc/agents 适配器体系——./mastra 可选子路径导出 MastraAdapter(把 Mastra 流事件映射为 StreamChunk 线协议)和 createMastraModel 声明式工厂,注册进既有 ModelRegistry,网关与前端零改动。dev-server 侧由 createMastraGateway 遍历 agent 定义逐个注册:
// packages/server/src/mastra/index.ts(createMastraGateway 内部,简化)
import { createMastraModel } from '@toimc/agents/mastra'
for (const def of agentDefinitions) {
const created = await createMastraModel({
id: def.id,
model: def.model, // 'deepseek/deepseek-chat' 或 { id, url } 兼容端点
...(def.instructions ? { instructions: def.instructions } : {}),
...(def.tools ? { tools: def.tools } : {}), // createTool 产物
...(def.memory ? { memory: def.memory() } : {}), // 工厂形式,惰性创建
})
registry.registerAdapter(def.id, created.adapter, created.info)
}
typescript
前端会话的 conversationId 由网关透传、适配器映射为 Mastra 的 memory.thread——同一会话自动携带上下文,LibSQL 文件库让对话跨进程重启保留。env 门控:MASTRA_MODEL 存在才注册(无 key 的默认环境行为不变)。
实测踩坑记录(仓库时点 @mastra/core 1.60,均已在实现中处理;2026-11-07 npm 最新已到 1.82.0,以下行为以官方 changelog 为准复核):
stream()无format: 'aisdk'选项,fullStream直接产 Mastra 原生 chunk({ type, payload }结构),工具事件名是tool-call-input-streaming-start/tool-call-delta/tool-call-input-streaming-end/tool-result/tool-errorcreateTool的execute(inputData, context)第一参数直接是校验后的输入(zod schema 产物),不是{ context }包装——官方文档现在也明确写了 "execute has exactly one signature"temperature走modelSettings.temperature,不是顶层选项- 工具入参流式分片产生双帧 tool_call(起点帧无参数 + 完整帧带参数),消费端必须按
toolCallId合并——core 的useChat和网关 collector 都补了原位合并逻辑 LibSQLStore构造必填id字段;本地文件模式不自动建父目录,.temp/缺失时 SQLITE_CANTOPEN 直接崩启动
监控面板
npx mastra dev 起 Studio(默认 4111 端口)。telemetry 默认开启(MASTRA_TELEMETRY=false 显式关闭):
- 每次对话的 token 用量和耗时
- 工具调用成功率和平均耗时
- 模型错误率和降级统计
通过 Dashboard 查看实时数据,也可用 API 拉取指标集成到自己的监控。(Mastra 现在还提供 Memory 记忆、Evals 评估、Workspaces 多租户等能力)
部署方案
Hono 支持多种运行时,部署灵活:
# Node.js(本项目 dev-server 的用法:@hono/node-server + serve())
pnpm add @hono/node-server
# import { serve } from '@hono/node-server'
# serve({ fetch: app.fetch, port: 8787 })
# Bun(高性能)
bun run src/index.ts
# Cloudflare Workers / Vercel 等边缘平台
# 按平台选适配器(wrangler deploy / vercel deploy),应用代码零改动
bash
用 AI 生成 API 服务
> 请为 @ai-chat/server 生成完整的 Hono 应用:
> 1. POST /api/chat 流式聊天(SSE 格式)
> 2. GET /api/models 获取模型列表
> 3. 认证中间件(Bearer Token)
> 4. 限流中间件(每分钟 30 次)
> 5. CORS 配置
>
> 参考 packages/agents/src/types.ts 的类型定义。
text
记住 15-02 的教训:AI 第一版方案常常把模型配置写死在环境变量里,迭代提示词直到需求"具体可供落地"(谁在哪个页面做什么操作、默认行为是什么、结果长什么样)再让它开发。
小结
- Hono 轻量高性能,14KB 零依赖,多运行时部署;SSE 用
streamSSE助手 +c.req.raw.signal断连透传 - 路由设计:POST /api/chat 流式聊天 + GET /api/models 模型列表 + /health 探活;中间件按层装配且不锁探活
- Mastra 工具机制的本质:工具 id/description/schema 随请求发给模型,模型按格式响应函数调用,框架映射成执行
- 天气 Agent 实战链路:ProviderSettingsDialog 表单 → 服务端运行时注册 → /api/models 可见 → 会话中 thinking + tool_call + 整合回复
- 三坑点自查:设置弹窗样式、会话智能命名(轻模型生成标题)、流式过渡反馈
- 实测踩坑五条(双帧合并/modelSettings/LibSQLStore id 与父目录)已沉淀进真实实现
第15章主体到此结束。下一节是一个资源篇——Vercel 的 Harness 工程顶级教程怎么用、怎么跟学。下一章讲 AI Agents 系统——自主决策的智能助手引擎。
↑